#!/usr/bin/python

# Copyright (c) 2016, William L Thomson Jr
# Copyright (c) 2013, Yap Sok Ann
# Written by Yap Sok Ann <sokann@gmail.com>
# Modified by William L. Thomson Jr. <wlt@o-sinc.com>
# Based on apt module written by Matthew Williams <matthew@flowroute.com>
#
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations


DOCUMENTATION = r"""
module: portage
short_description: Package manager for Gentoo
description:
  - Manages Gentoo packages.
extends_documentation_fragment:
  - community.general.attributes

attributes:
  check_mode:
    support: full
  diff_mode:
    support: none

options:
  package:
    description:
      - Package atom or set, for example V(sys-apps/foo) or V(>foo-2.13) or V(@world).
    aliases: [name]
    type: list
    elements: str

  state:
    description:
      - State of the package atom.
    default: "present"
    choices: ["present", "installed", "emerged", "absent", "removed", "unmerged", "latest"]
    type: str

  update:
    description:
      - Update packages to the best version available (C(--update)).
    type: bool
    default: false

  backtrack:
    description:
      - Set backtrack value (C(--backtrack)).
    type: int
    version_added: 5.8.0

  deep:
    description:
      - Consider the entire dependency tree of packages (C(--deep)).
    type: bool
    default: false

  newuse:
    description:
      - Include installed packages where USE flags have changed (C(--newuse)).
    type: bool
    default: false

  changed_deps:
    description:
      - Tells emerge to replace installed packages for which the ebuild dependencies
        have changed since the packages were built (C(--changed-deps)).
    type: bool
    default: false
    version_added: 12.0.0

  changed_use:
    description:
      - Include installed packages where USE flags have changed, except when.
      - Flags that the user has not enabled are added or removed.
      - (C(--changed-use)).
    type: bool
    default: false

  oneshot:
    description:
      - Do not add the packages to the world file (C(--oneshot)).
    type: bool
    default: false

  noreplace:
    description:
      - Do not re-emerge installed packages (C(--noreplace)).
    type: bool
    default: true

  nodeps:
    description:
      - Only merge packages but not their dependencies (C(--nodeps)).
    type: bool
    default: false

  onlydeps:
    description:
      - Only merge packages' dependencies but not the packages (C(--onlydeps)).
    type: bool
    default: false

  depclean:
    description:
      - Remove packages not needed by explicitly merged packages (C(--depclean)).
      - If no package is specified, clean up the world's dependencies.
      - Otherwise, C(--depclean) serves as a dependency aware version of C(--unmerge).
    type: bool
    default: false

  quiet:
    description:
      - Run emerge in quiet mode (C(--quiet)).
    type: bool
    default: false

  verbose:
    description:
      - Run emerge in verbose mode (C(--verbose)).
    type: bool
    default: false

  select:
    description:
      - If set to V(true), explicitely add the package to the world file.
      - Please note that this option is not used for idempotency, it is only used when actually installing a package.
    type: bool
    version_added: 8.6.0

  sync:
    description:
      - Sync package repositories first.
      - If V(yes), perform C(emerge --sync).
      - If V(web), perform C(emerge-webrsync).
    choices: ["web", "yes", "no"]
    type: str

  getbinpkgonly:
    description:
      - Merge only packages specified at C(PORTAGE_BINHOST) in C(make.conf).
    type: bool
    default: false
    version_added: 1.3.0

  getbinpkg:
    description:
      - Prefer packages specified at C(PORTAGE_BINHOST) in C(make.conf).
    type: bool
    default: false

  usepkgonly:
    description:
      - Merge only binaries (no compiling).
    type: bool
    default: false

  usepkg:
    description:
      - Tries to use the binary package(s) in the locally available packages directory.
    type: bool
    default: false

  keepgoing:
    description:
      - Continue as much as possible after an error.
    type: bool
    default: false

  jobs:
    description:
      - Specifies the number of packages to build simultaneously.
      - 'Since version 2.6: Value of V(0) or V(false) resets any previously added C(--jobs) setting values.'
    type: int

  loadavg:
    description:
      - Specifies that no new builds should be started if there are other builds running and the load average is at least
        LOAD.
      - 'Since version 2.6: Value of 0 or False resets any previously added C(--load-average) setting values.'
    type: float

  withbdeps:
    description:
      - Specifies that build time dependencies should be installed.
    type: bool
    version_added: 5.8.0

  quietbuild:
    description:
      - Redirect all build output to logs alone, and do not display it on stdout (C(--quiet-build)).
    type: bool
    default: false

  quietfail:
    description:
      - Suppresses display of the build log on stdout (--quiet-fail).
      - Only the die message and the path of the build log are displayed on stdout.
    type: bool
    default: false

author:
  - "William L Thomson Jr (@wltjr)"
  - "Yap Sok Ann (@sayap)"
  - "Andrew Udvare (@Tatsh)"
"""

EXAMPLES = r"""
- name: Make sure package foo is installed
  community.general.portage:
    package: foo
    state: present

- name: Make sure package foo is not installed
  community.general.portage:
    package: foo
    state: absent

- name: Update package foo to the latest version (os specific alternative to latest)
  community.general.portage:
    package: foo
    update: true

- name: Install package foo using PORTAGE_BINHOST setup
  community.general.portage:
    package: foo
    getbinpkg: true

- name: Re-install world from binary packages only and do not allow any compiling
  community.general.portage:
    package: '@world'
    usepkgonly: true

- name: Sync repositories and update world
  community.general.portage:
    package: '@world'
    update: true
    deep: true
    sync: true

- name: Remove unneeded packages
  community.general.portage:
    depclean: true

- name: Remove package foo if it is not explicitly needed
  community.general.portage:
    package: foo
    state: absent
    depclean: true
"""

import os
import re
import sys
import traceback

from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils.common.respawn import has_respawned, respawn_module


try:
    from portage.dbapi import vartree
    from portage.exception import InvalidAtom

    HAS_PORTAGE = True
    PORTAGE_IMPORT_ERROR = None
except ImportError:
    HAS_PORTAGE = False
    PORTAGE_IMPORT_ERROR = traceback.format_exc()


def query_package(module, package, action):
    if package.startswith("@"):
        return query_set(module, package, action)
    return query_atom(module, package, action)


def query_atom(module, atom, action):
    vdb = vartree.vardbapi()
    try:
        exists = vdb.match(atom)
    except InvalidAtom:
        return False
    return bool(exists)


def query_set(module, set_, action):
    system_sets = [
        "@live-rebuild",
        "@module-rebuild",
        "@preserved-rebuild",
        "@security",
        "@selected",
        "@system",
        "@world",
        "@x11-module-rebuild",
    ]

    if set_ in system_sets:
        if action == "unmerge":
            module.fail_json(msg=f"set {set_} cannot be removed")
        return False

    world_sets_path = "/var/lib/portage/world_sets"
    if not os.path.exists(world_sets_path):
        return False

    cmd = ["grep", set_, world_sets_path]

    rc, out, err = module.run_command(cmd)
    return rc == 0


def sync_repositories(module, webrsync=False):
    if module.check_mode:
        module.exit_json(msg="check mode not supported by sync")

    if webrsync:
        webrsync_path = module.get_bin_path("emerge-webrsync", required=True)
        cmd = [webrsync_path, "--quiet"]
    else:
        cmd = [module.emerge_path, "--sync", "--quiet", "--ask=n"]

    rc, out, err = module.run_command(cmd)
    if rc != 0:
        module.fail_json(msg="could not sync package repositories")


# Note: In the 3 functions below, package querying is done one-by-one,
# but emerge is done in one go. If that is not desirable, split the
# packages into multiple tasks instead of joining them together with
# comma.


def emerge_packages(module, packages):
    """Run emerge command against given list of atoms."""
    p = module.params

    if (
        p["noreplace"]
        and not p["changed_deps"]
        and not p["changed_use"]
        and not p["newuse"]
        and not (p["update"] or p["state"] == "latest")
    ):
        for package in packages:
            if (
                p["noreplace"]
                and not p["changed_deps"]
                and not p["changed_use"]
                and not p["newuse"]
                and not query_package(module, package, "emerge")
            ):
                break
        else:
            module.exit_json(changed=False, msg="Packages already present.")
        if module.check_mode:
            module.exit_json(changed=True, msg="Packages would be installed.")

    args = []
    emerge_flags = {
        "update": "--update",
        "deep": "--deep",
        "newuse": "--newuse",
        "changed_deps": "--changed-deps",
        "changed_use": "--changed-use",
        "oneshot": "--oneshot",
        "noreplace": "--noreplace",
        "nodeps": "--nodeps",
        "onlydeps": "--onlydeps",
        "quiet": "--quiet",
        "verbose": "--verbose",
        "getbinpkgonly": "--getbinpkgonly",
        "getbinpkg": "--getbinpkg",
        "usepkgonly": "--usepkgonly",
        "usepkg": "--usepkg",
        "keepgoing": "--keep-going",
        "quietbuild": "--quiet-build",
        "quietfail": "--quiet-fail",
    }
    for flag, arg in emerge_flags.items():
        if p[flag]:
            args.append(arg)

    if p["state"] and p["state"] == "latest":
        args.append("--update")

    emerge_flags = {
        "jobs": "--jobs",
        "loadavg": "--load-average",
        "backtrack": "--backtrack",
        "withbdeps": "--with-bdeps",
        "select": "--select",
    }

    for flag, arg in emerge_flags.items():
        flag_val = p[flag]

        if flag_val is None:
            """Fallback to default: don't use this argument at all."""
            continue

        """Add the --flag=value pair."""
        if isinstance(flag_val, bool):
            args.extend((arg, "y" if flag_val else "n"))
        elif not flag_val:
            """If the value is 0 or 0.0: add the flag, but not the value."""
            args.append(arg)
        else:
            args.extend((arg, str(flag_val)))

    cmd, (rc, out, err) = run_emerge(module, packages, *args)
    if rc != 0:
        module.fail_json(
            cmd=cmd,
            rc=rc,
            stdout=out,
            stderr=err,
            msg="Packages not installed.",
        )

    # Check for SSH error with PORTAGE_BINHOST, since rc is still 0 despite
    #   this error
    if (p["usepkgonly"] or p["getbinpkg"] or p["getbinpkgonly"]) and "Permission denied (publickey)." in err:
        module.fail_json(
            cmd=cmd,
            rc=rc,
            stdout=out,
            stderr=err,
            msg="Please check your PORTAGE_BINHOST configuration in make.conf and your SSH authorized_keys file",
        )

    changed = True
    for line in out.splitlines():
        if re.match(r"(?:>+) Emerging (?:binary )?\(1 of", line):
            msg = "Packages installed."
            break
        elif module.check_mode and re.match(r"\[(binary|ebuild)", line):
            msg = "Packages would be installed."
            break
    else:
        changed = False
        msg = "No packages installed."

    module.exit_json(
        changed=changed,
        cmd=cmd,
        rc=rc,
        stdout=out,
        stderr=err,
        msg=msg,
    )


def unmerge_packages(module, packages):
    p = module.params

    for package in packages:
        if query_package(module, package, "unmerge"):
            break
    else:
        module.exit_json(changed=False, msg="Packages already absent.")

    args = ["--unmerge"]

    for flag in ["quiet", "verbose"]:
        if p[flag]:
            args.append(f"--{flag}")

    cmd, (rc, out, err) = run_emerge(module, packages, *args)

    if rc != 0:
        module.fail_json(
            cmd=cmd,
            rc=rc,
            stdout=out,
            stderr=err,
            msg="Packages not removed.",
        )

    module.exit_json(
        changed=True,
        cmd=cmd,
        rc=rc,
        stdout=out,
        stderr=err,
        msg="Packages removed.",
    )


def cleanup_packages(module, packages):
    p = module.params

    if packages:
        for package in packages:
            if query_package(module, package, "unmerge"):
                break
        else:
            module.exit_json(changed=False, msg="Packages already absent.")

    args = ["--depclean"]

    for flag in ["quiet", "verbose"]:
        if p[flag]:
            args.append(f"--{flag}")

    cmd, (rc, out, err) = run_emerge(module, packages, *args)
    if rc != 0:
        module.fail_json(cmd=cmd, rc=rc, stdout=out, stderr=err)

    removed = 0
    for line in out.splitlines():
        if not line.startswith("Number removed:"):
            continue
        parts = line.split(":")
        removed = int(parts[1].strip())
    changed = removed > 0

    module.exit_json(
        changed=changed,
        cmd=cmd,
        rc=rc,
        stdout=out,
        stderr=err,
        msg="Depclean completed.",
    )


def run_emerge(module, packages, *args):
    args = list(args)

    args.append("--ask=n")
    if module.check_mode:
        args.append("--pretend")

    cmd = [module.emerge_path] + args + packages
    return cmd, module.run_command(cmd)


portage_present_states = ["present", "emerged", "installed", "latest"]
portage_absent_states = ["absent", "unmerged", "removed"]


def main():
    module = AnsibleModule(
        argument_spec=dict(
            package=dict(type="list", elements="str", aliases=["name"]),
            state=dict(
                default=portage_present_states[0],
                choices=portage_present_states + portage_absent_states,
            ),
            update=dict(default=False, type="bool"),
            backtrack=dict(type="int"),
            deep=dict(default=False, type="bool"),
            newuse=dict(default=False, type="bool"),
            changed_deps=dict(default=False, type="bool"),
            changed_use=dict(default=False, type="bool"),
            oneshot=dict(default=False, type="bool"),
            noreplace=dict(default=True, type="bool"),
            nodeps=dict(default=False, type="bool"),
            onlydeps=dict(default=False, type="bool"),
            depclean=dict(default=False, type="bool"),
            select=dict(type="bool"),
            quiet=dict(default=False, type="bool"),
            verbose=dict(default=False, type="bool"),
            sync=dict(choices=["yes", "web", "no"]),
            getbinpkgonly=dict(default=False, type="bool"),
            getbinpkg=dict(default=False, type="bool"),
            usepkgonly=dict(default=False, type="bool"),
            usepkg=dict(default=False, type="bool"),
            keepgoing=dict(default=False, type="bool"),
            jobs=dict(type="int"),
            loadavg=dict(type="float"),
            withbdeps=dict(type="bool"),
            quietbuild=dict(default=False, type="bool"),
            quietfail=dict(default=False, type="bool"),
        ),
        required_one_of=[["package", "sync", "depclean"]],
        mutually_exclusive=[
            ["nodeps", "onlydeps"],
            ["quiet", "verbose"],
            ["quietbuild", "verbose"],
            ["quietfail", "verbose"],
            ["oneshot", "select"],
        ],
        supports_check_mode=True,
    )

    if not HAS_PORTAGE:
        if sys.executable != "/usr/bin/python" and not has_respawned():
            respawn_module("/usr/bin/python")
        else:
            module.fail_json(msg=missing_required_lib("portage"), exception=PORTAGE_IMPORT_ERROR)

    module.emerge_path = module.get_bin_path("emerge", required=True)

    p = module.params

    if p["sync"] and p["sync"].strip() != "no":
        sync_repositories(module, webrsync=(p["sync"] == "web"))
        if not p["package"]:
            module.exit_json(msg="Sync successfully finished.")

    packages = []
    if p["package"]:
        packages.extend(p["package"])

    if p["depclean"]:
        if packages and p["state"] not in portage_absent_states:
            module.fail_json(
                msg=f"Depclean can only be used with package when the state is one of: {portage_absent_states}",
            )

        cleanup_packages(module, packages)

    elif p["state"] in portage_present_states:
        emerge_packages(module, packages)

    elif p["state"] in portage_absent_states:
        unmerge_packages(module, packages)


if __name__ == "__main__":
    main()
